Skip to content

feat: add ssh port forwarding for remote previews - #2544

Merged
jschwxrz merged 11 commits into
mainfrom
feat-port-forwarding
Jun 17, 2026
Merged

feat: add ssh port forwarding for remote previews#2544
jschwxrz merged 11 commits into
mainfrom
feat-port-forwarding

Conversation

@jschwxrz

Copy link
Copy Markdown
Collaborator
  • add automatic ssh port forwarding when terminal output detects local preview urls
  • replace host rewriting with local loopback urls backed by ssh tunnels
  • add preview server state for direct and forwarded previews with status and lifecycle cleanup
  • surface preview server controls for open, copy, stop, restart and manual forwarding
  • remove legacy host preview events and dev server store/pills

#2458

@arnestrickmann

Copy link
Copy Markdown
Contributor

Very nice, works great.

One thing I noticed:

When I click a URL for a started dev server in the terminal drawer and open it in the Emdash browser, the URL includes a trailing ) at the end. Because of that, the dev server doesn’t open correctly until I manually remove the ) from the URL.

@jschwxrz
jschwxrz marked this pull request as ready for review June 17, 2026 18:08
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the legacy host-rewriting dev-server detection approach with a full SSH port-forwarding pipeline. Terminal output is now scanned for local URLs, which are registered as either direct (localhost / 127.0.0.1) or forwarded (SSH tunnel via node:net → SSH channel) preview servers, surfaced to the renderer through a new PreviewServerStore and per-server pill UI.

  • Introduces PortForwardService / PortForwardTunnel for managing local TCP listeners that proxy through SSH channels, with preferred-port / port-0 fallback and onConnectionError callbacks.
  • Adds PreviewServerService to coordinate server lifecycle (auto-detect from terminal output, manual forward, restart, stop) with concurrent-stop guards and SSH connection event handling.
  • Replaces the old dev-server-watcher and dev-server-store with the new wireTerminalUrlDetector + PreviewServerStore, and surfaces open/copy/stop/restart controls in a dropdown pill component.

Confidence Score: 4/5

Safe to merge for most users; two edge-case resource leaks in the port-forward layer are worth tracking but do not affect normal usage.

The concurrent-restart path in PortForwardService.open can produce an orphaned TCP listener, and the 250ms PTY-exit guard can transiently drop a forwarded preview server after SSH reconnect without guaranteed re-detection — both only matter in narrow race windows and are self-healing through normal user actions. Core correctness of the auto-detect, manual forward, and teardown paths is solid and well-covered by the new test suite.

port-forward-service.ts (concurrent open guard) and preview-server-service.ts (PTY-exit timeout interaction with reconnect/rehydrate).

Important Files Changed

Filename Overview
apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts New service managing SSH port-forward tunnels; clean abstraction, but open has a same-ID concurrency window that can orphan a TCP server if called twice before either resolves.
apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts Implements TCP-to-SSH-channel forwarding using node:net; correctly handles EADDRINUSE fallback to port 0 and cleans up sockets on close.
apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts Core orchestration of preview server lifecycle; forwardManual correctly adds the server before async ops to avoid teardown leaks. The SSH PTY-exit→reconnect path has a window where the server is transiently removed and may not re-appear if the remote dev server doesn't re-emit its URL.
apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.ts Parses and deduplicates local URLs from PTY output; correctly strips ANSI, normalizes hosts, and probes local ports with a consecutive-failure threshold.
apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts Renderer-side MobX store with event-driven sync and optimistic local updates for stop/restart/forwardManual; workspace filtering is correct.
apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx Dropdown pill component for preview servers; correctly gates Open/Copy actions on canOpen/hasUrl and displays forwarding metadata.
apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-dialog.tsx New dialog for manually forwarding a port; validates port range, handles async submission, and surfaces error messages.
apps/emdash-desktop/src/shared/core/preview-servers/types.ts Shared type definitions for direct and forwarded preview servers; previewServerUrl correctly returns null when localPort is undefined.
apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts Wires wireTerminalUrlDetector (replacing old dev-server-watcher) with probeLocalPorts: false and forwards detected URLs via the singleton previewServerService.
apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts Wires URL detector for local terminals with probeLocalPorts: true; registers direct preview servers with host/port from detected output.
apps/emdash-desktop/src/shared/terminal-url.ts Extracted from external-url.ts so the same normalization logic is usable in shared/main context; behavior is identical to the removed code.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant PTY as PTY (SSH/Local)
    participant URLDet as TerminalUrlDetector
    participant PSS as PreviewServerService
    participant PFS as PortForwardService
    participant Tunnel as PortForwardTunnel (node:net)
    participant SSH as SSH Channel
    participant Store as PreviewServerStore (renderer)

    PTY->>URLDet: onData(chunk)
    URLDet->>URLDet: "match URL pattern, parse & deduplicate"
    URLDet->>PSS: onDetected(server)
    PSS->>PSS: registerSshTarget / registerLocalTarget
    PSS->>Store: emit upsert (status: starting)
    PSS->>PFS: open(tunnelId, remotePort)
    PFS->>Tunnel: bind TCP server on localhost:preferredPort
    Tunnel->>SSH: forwardOut(remotePort)
    Tunnel-->>PFS: localPort
    PFS-->>PSS: PortForwardRecord
    PSS->>Store: emit upsert (status: ready, localPort)

    Note over PTY,Store: Manual forward path
    Store->>PSS: forwardManual(connectionId, remotePort)
    PSS->>Store: emit upsert (status: starting)
    PSS->>PFS: open(tunnelId, remotePort)
    PFS-->>PSS: PortForwardRecord
    PSS->>Store: emit upsert (status: ready, localPort)

    Note over PTY,Store: SSH connection events
    SSH-->>PSS: handleSshConnectionEvent(reconnected/failed)
    PSS->>Store: emit upsert (status: ready/reconnecting/failed)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant PTY as PTY (SSH/Local)
    participant URLDet as TerminalUrlDetector
    participant PSS as PreviewServerService
    participant PFS as PortForwardService
    participant Tunnel as PortForwardTunnel (node:net)
    participant SSH as SSH Channel
    participant Store as PreviewServerStore (renderer)

    PTY->>URLDet: onData(chunk)
    URLDet->>URLDet: "match URL pattern, parse & deduplicate"
    URLDet->>PSS: onDetected(server)
    PSS->>PSS: registerSshTarget / registerLocalTarget
    PSS->>Store: emit upsert (status: starting)
    PSS->>PFS: open(tunnelId, remotePort)
    PFS->>Tunnel: bind TCP server on localhost:preferredPort
    Tunnel->>SSH: forwardOut(remotePort)
    Tunnel-->>PFS: localPort
    PFS-->>PSS: PortForwardRecord
    PSS->>Store: emit upsert (status: ready, localPort)

    Note over PTY,Store: Manual forward path
    Store->>PSS: forwardManual(connectionId, remotePort)
    PSS->>Store: emit upsert (status: starting)
    PSS->>PFS: open(tunnelId, remotePort)
    PFS-->>PSS: PortForwardRecord
    PSS->>Store: emit upsert (status: ready, localPort)

    Note over PTY,Store: SSH connection events
    SSH-->>PSS: handleSshConnectionEvent(reconnected/failed)
    PSS->>Store: emit upsert (status: ready/reconnecting/failed)
Loading

Reviews (2): Last reviewed commit: "fix(preview): avoid focusing disabled pr..." | Re-trigger Greptile

@jschwxrz

Copy link
Copy Markdown
Collaborator Author

@greptileai

@jschwxrz
jschwxrz merged commit 4c1f31d into main Jun 17, 2026
1 check passed
luisKisters pushed a commit to luisKisters/emdash that referenced this pull request Jul 29, 2026
…rwarding

feat: add ssh port forwarding for remote previews
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants